Skip to content

fix(web-integration): prevent dropped characters when input re-renders mid-type#2518

Open
quanru wants to merge 5 commits into
mainfrom
fix/web-clear-then-type-race
Open

fix(web-integration): prevent dropped characters when input re-renders mid-type#2518
quanru wants to merge 5 commits into
mainfrom
fix/web-clear-then-type-race

Conversation

@quanru

@quanru quanru commented May 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

typeText in replace mode (the default for the Input action) calls clearInput and then immediately runs keyboard.type. clearInput fires a synthetic input event with value === "", which many controlled components react to by re-rendering and sometimes replacing the input element entirely. If that replacement lands after clearInput returns but during keyboard.type, characters can land on the detached old element and be dropped.

Fix

  • Add Page.waitForDomQuiet({ quietMs, timeoutMs, target }) to packages/web-integration/src/puppeteer/base-page.ts. It uses a MutationObserver scoped to the target input's stable ancestor (closest("form"), then parent element, then document.body as fallback), so unrelated page-level mutations do not keep resetting the quiet timer.
  • Declare waitForDomQuiet as an optional method on AbstractWebPage so shared input code can call it safely.
  • In createWebInputPrimitives replace mode, call await page.waitForDomQuiet?.({ target }) between clearInput and keyboard.type. Async re-renders triggered by clearing are now absorbed before typing starts.

Regression test

packages/web-integration/tests/unit-test/puppeteer/input-clear-then-type-race.test.ts drives a real Puppeteer browser against a page that replaces the input element after the clear-triggered input event. The production Input action path now preserves the complete value (Hello).

The same test file also covers the scoped observer behavior: unrelated mutations outside the target ancestor no longer force the wait to run until the hard timeout.

Test plan

  • pnpm run lint
  • npx nx test @midscene/web -- input-clear-then-type-race.test.ts

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 22, 2026

Copy link
Copy Markdown

Deploying midscene with  Cloudflare Pages  Cloudflare Pages

Latest commit: 15a1239
Status: ✅  Deploy successful!
Preview URL: https://7e7f89d4.midscene.pages.dev
Branch Preview URL: https://fix-web-clear-then-type-race.midscene.pages.dev

View logs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3945686707

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +480 to +484
obs.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
characterData: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope DOM-quiet wait to the input subtree

waitForDomQuiet observes document.body with subtree/attributes/characterData, so any unrelated mutations anywhere on the page keep resetting the quiet timer until the 500ms hard timeout. Because typeText now calls this on every replace-mode input (web-page.ts), dynamic pages with ticking counters/spinners can incur an extra ~500ms per field and may hit higher-level action timeouts even when the target input is stable. Limit observation to the target element (or a narrow ancestor) so unrelated DOM churn does not throttle typing.

Useful? React with 👍 / 👎.

@quanru quanru force-pushed the fix/web-clear-then-type-race branch from 1d4fd7f to 11b69f3 Compare May 27, 2026 03:51
@quanru quanru marked this pull request as draft May 28, 2026 11:13
@quanru quanru force-pushed the fix/web-clear-then-type-race branch from af20059 to c86b89d Compare June 2, 2026 06:35
quanru added 4 commits June 8, 2026 18:34
…s mid-type

In `replace` mode, `typeText` calls `clearInput` and then immediately
runs `keyboard.type`. `clearInput` fires a synthetic `input` event with
`value === ''`, which many controlled components react to by
re-rendering and replacing the input element. If that replacement lands
AFTER `clearInput` returns but DURING `keyboard.type`, the first
characters land on the now-detached old element and are lost.

Add `Page.waitForDomQuiet({ quietMs, timeoutMs })` that uses a
`MutationObserver` to wait until the DOM has been mutation-free for
`quietMs` (default 100ms), bounded by `timeoutMs` (default 500ms).
Call it in the replace branch of `typeText` between `clearInput` and
`keyboard.type` so async re-renders are absorbed before typing starts.

Adds a deterministic puppeteer-driven regression test that simulates
the re-render pattern and demonstrates the bug on the raw path while
verifying the fix on the production `Input` action path.
…tion

The initial `settleTimer = setTimeout(done, q)` caused the
MutationObserver to disconnect after `quietMs` even when nothing had
mutated yet. Frameworks that re-render later than `quietMs` (e.g. via
`setTimeout` or async effects) were missed, so the typed characters
still landed on the about-to-be-replaced element.

Drop the initial settleTimer: only resolve on (a) `quietMs` of stillness
after at least one observed mutation, or (b) the `timeoutMs` cap. The
no-mutation path now waits the full `timeoutMs` before returning, which
is the unavoidable cost of catching late re-renders.
@quanru quanru force-pushed the fix/web-clear-then-type-race branch from c86b89d to 650cc3f Compare June 8, 2026 10:37
@quanru quanru marked this pull request as ready for review June 9, 2026 05:34

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 650cc3f307

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

attributes: true,
characterData: true,
});
const hardTimer = setTimeout(done, total);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Start the quiet timer before waiting for mutations

Separate from the earlier subtree-scope comment, the fresh issue here is that waitForDomQuiet only schedules settleTimer from the MutationObserver callback; when a replace-mode Input clears a normal/static field and no DOM mutation follows, the promise cannot resolve after quietMs and always waits for the 500 ms hard timeout. Because this path now runs for every default input replacement, filling otherwise stable forms gets an avoidable 500 ms delay per field and can push higher-level actions over their timeout; start the settle timer immediately after observe so the no-mutation case resolves after quietMs.

Useful? React with 👍 / 👎.

@ottomao ottomao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we just use a hard sleep ?

@quanru

quanru commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator Author

@ottomao Thanks, I checked the hard-sleep option.

I do not think a fixed 300ms sleep is sufficient for this race. In the reproduced case, the input is replaced 450ms after the clear-triggered input event; replacing the DOM-quiet wait with a 300ms sleep still loses characters (Hello becomes ello). A longer fixed sleep would be a guess and would add unconditional latency to every replace-mode input.

I kept the DOM-quiet wait, but narrowed its scope in 15a1239e2: it now observes the target input’s stable ancestor (closest("form"), then parent element, with document.body only as fallback) instead of the whole page. This preserves coverage for parent.replaceChild(input) while avoiding unrelated page-level mutations from resetting the timer.

I also added coverage for the scoped behavior in input-clear-then-type-race.test.ts.

Validation:

  • npx nx test @midscene/web -- input-clear-then-type-race.test.ts
  • pnpm run lint

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants